/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */

import { strict as assert } from "assert";
import * as path from "path";

import { TypedEventEmitter } from "@fluid-internal/client-utils";
import {
	AcceptanceCondition,
	AsyncGenerator,
	Reducer,
	combineReducers,
	createWeightedAsyncGenerator,
	takeAsync,
} from "@fluid-private/stochastic-test-utils";
import {
	DDSFuzzModel,
	DDSFuzzSuiteOptions,
	DDSFuzzTestState,
	registerOracle,
	type DDSFuzzHarnessEvents,
} from "@fluid-private/test-dds-utils";
import {
	IChannelAttributes,
	IFluidDataStoreRuntime,
	type Serializable,
	IChannelServices,
} from "@fluidframework/datastore-definitions/internal";
import {
	endpointPosAndSide,
	PropertySet,
	Side,
	type AdjustParams,
	type InteriorSequencePlace,
	type MapLike,
	type SequencePlace,
} from "@fluidframework/merge-tree/internal";

import {
	toOptionalSequencePlace,
	toSequencePlace,
	type IntervalCollection,
	type ISequenceIntervalCollection,
} from "../../intervalCollection.js";
import { SharedStringRevertible, revertSharedStringRevertibles } from "../../revertibles.js";
import { SharedStringFactory } from "../../sequenceFactory.js";
import { ISharedString, type SharedStringClass } from "../../sharedString.js";
import { _dirname } from "../dirname.cjs";
import { assertEquivalentSharedStrings } from "../intervalTestUtils.js";

import { hasSharedStringOracle, type IChannelWithOracles } from "./oracleUtils.js";
import { SharedStringOracle } from "./sharedStringOracle.js";

export type RevertibleSharedString = ISharedString & {
	revertibles: SharedStringRevertible[];
	// This field prevents change events that are emitted while in the process of a revert from
	// being added into the revertibles stack.
	isCurrentRevert: boolean;
};
export function isRevertibleSharedString(s: ISharedString): s is RevertibleSharedString {
	return (s as RevertibleSharedString).revertibles !== undefined;
}

export interface RangeSpec {
	start: number;
	end: number;
}

export interface IntervalCollectionSpec {
	collectionName: string;
}

export interface AddText {
	type: "addText";
	index: number;
	content: string;
	properties?: PropertySet;
}

export interface AddPoisonedText {
	type: "addPoisonedText";
	index: number;
	content: string;
	properties?: PropertySet;
}

export interface RemoveRange extends RangeSpec {
	type: "removeRange";
}

export interface AnnotateRange extends RangeSpec {
	type: "annotateRange";
	props: { key: string; value?: Serializable<any> }[];
}

export interface AnnotateAdjustRange extends RangeSpec {
	type: "annotateAdjustRange";
	adjust: { key: string; value: AdjustParams }[];
}

export interface ObliterateRange {
	type: "obliterateRange";
	start: number | InteriorSequencePlace;
	end: number | InteriorSequencePlace;
}

// For non-interval collection fuzzing, annotating text would also be useful.
export interface AddInterval extends IntervalCollectionSpec {
	type: "addInterval";
	// Normally interval ids get autogenerated, but including it here allows tracking
	// what happened to an interval over the course of its lifetime based on the history
	// file, which is useful for debugging test failures.
	id: string;
	start: SequencePlace;
	end: SequencePlace;
}

export interface ChangeInterval extends IntervalCollectionSpec {
	type: "changeInterval";
	start: SequencePlace | undefined;
	end: SequencePlace | undefined;
	id: string;
	properties: PropertySet | undefined;
}

export interface DeleteInterval extends IntervalCollectionSpec {
	type: "deleteInterval";
	id: string;
}

export interface RevertSharedStringRevertibles {
	type: "revertSharedStringRevertibles";
	editsToRevert: number;
}

export interface RevertibleWeights {
	revertWeight: number;
	addText: number;
	removeRange: number;
	obliterateRange: number;
	addInterval: number;
	deleteInterval: number;
	changeInterval: number;
}

export type IntervalOperation = AddInterval | ChangeInterval | DeleteInterval;
export type OperationWithRevert = IntervalOperation | RevertSharedStringRevertibles;
export type TextOperation =
	| AddText
	| RemoveRange
	| AnnotateRange
	| AnnotateAdjustRange
	| ObliterateRange;

export type ClientOperation = IntervalOperation | TextOperation;

export type RevertOperation = OperationWithRevert | TextOperation;
export type Operation = RevertOperation;

export type FuzzTestState = DDSFuzzTestState<SharedStringFactory>;

export interface SharedStringOperationGenerationConfig {
	/**
	 * Maximum length of the SharedString (locally) before no further AddText operations are generated.
	 * Note due to concurrency, during test execution the actual length of the string may exceed this.
	 */
	maxStringLength?: number;
	maxInsertLength?: number;
	weights?: {
		addText: number;
		removeRange: number;
		annotateRange: number;
		obliterateRange: number;
	};
	propertyNamePool?: string[];
}

export interface IntervalOperationGenerationConfig
	extends SharedStringOperationGenerationConfig {
	/**
	 * Maximum number of intervals (locally) before no further AddInterval operations are generated.
	 * Note due to concurrency, during test execution the actual number of intervals may exceed this.
	 */
	maxIntervals?: number;
	intervalCollectionNamePool?: string[];
	propertyNamePool?: string[];
	validateInterval?: number;
	weights?: RevertibleWeights & SharedStringOperationGenerationConfig["weights"];
}

export const defaultSharedStringOperationGenerationConfig: Required<SharedStringOperationGenerationConfig> =
	{
		maxStringLength: 1000,
		maxInsertLength: 10,
		weights: {
			addText: 2,
			removeRange: 1,
			annotateRange: 1,
			obliterateRange: 1,
		},
		propertyNamePool: ["prop1", "prop2", "prop3"],
	};
export const defaultIntervalOperationGenerationConfig: Required<IntervalOperationGenerationConfig> =
	{
		...defaultSharedStringOperationGenerationConfig,
		maxIntervals: 100,
		intervalCollectionNamePool: ["comments"],
		validateInterval: 100,
		weights: {
			...defaultSharedStringOperationGenerationConfig.weights,
			revertWeight: 2,
			addInterval: 2,
			deleteInterval: 2,
			changeInterval: 2,
			obliterateRange: 0,
		},
	};

export interface LoggingInfo {
	/** id of the interval to track over time */
	intervalId: string;
	/** Clients to print */
	clientIds: string[];
}

function logCurrentState(state: FuzzTestState, loggingInfo: LoggingInfo): void {
	for (const id of loggingInfo.clientIds) {
		const { channel } = state.clients.find((s) => s.containerRuntime.clientId === id) ?? {};
		assert(channel);
		const labels = channel.getIntervalCollectionLabels();
		const interval = Array.from(labels)
			.map((label) =>
				channel.getIntervalCollection(label).getIntervalById(loggingInfo.intervalId),
			)
			.find((result) => result !== undefined);

		console.log(`Client ${id}:`);
		if (interval !== undefined) {
			const start = channel.localReferencePositionToPosition(interval.start);
			const end = channel.localReferencePositionToPosition(interval.end);
			if (end === start) {
				console.log(`${" ".repeat(start)}x`);
			} else {
				console.log(`${" ".repeat(start)}[${" ".repeat(end - start - 1)}]`);
			}
		}
		console.log(channel.getText());
		console.log("\n");
	}
}

type ClientOpState = FuzzTestState;

export function makeReducer<TState extends FuzzTestState>(
	loggingInfo?: LoggingInfo,
): Reducer<Operation, TState> {
	const withLogging =
		(baseReducer: Reducer<Operation, TState>): Reducer<Operation, TState> =>
		(state, operation) => {
			if (loggingInfo !== undefined) {
				logCurrentState(state, loggingInfo);
				console.log("-".repeat(20));
				console.log("Next operation:", JSON.stringify(operation, undefined, 4));
			}
			baseReducer(state, operation);
		};

	const reducer = combineReducers<Operation, TState>({
		addText: ({ client }, { index, content, properties }) => {
			client.channel.insertText(index, content, properties);
		},
		removeRange: ({ client }, { start, end }) => {
			client.channel.removeRange(start, end);
		},
		annotateRange: ({ client }, { start, end, props }) => {
			const propertySet: PropertySet = {};
			for (const { key, value } of props) {
				propertySet[key] = value;
			}
			client.channel.annotateRange(start, end, propertySet);
		},
		annotateAdjustRange: ({ client }, { start, end, adjust }) => {
			const adjustRange: MapLike<AdjustParams> = {};
			for (const { key, value } of adjust) {
				adjustRange[key] = value;
			}
			client.channel.annotateAdjustRange(start, end, adjustRange);
		},
		obliterateRange: ({ client }, { start, end }) => {
			client.channel.obliterateRange(start, end);
		},
		addInterval: ({ client }, { start, end, collectionName, id }) => {
			const collection = client.channel.getIntervalCollection(
				collectionName,
			) as IntervalCollection;
			collection.add({
				id,
				start,
				end,
			});
		},
		deleteInterval: ({ client }, { id, collectionName }) => {
			const collection = client.channel.getIntervalCollection(collectionName);
			collection.removeIntervalById(id);
		},
		changeInterval: ({ client }, { id, start, end, collectionName, properties }) => {
			const collection = client.channel.getIntervalCollection(collectionName);
			if (start !== undefined && end !== undefined) {
				collection.change(id, {
					start,
					end,
					props: properties,
				});
			} else {
				collection.change(id, { props: properties });
			}
		},
		revertSharedStringRevertibles: ({ client }, { editsToRevert }) => {
			assert(isRevertibleSharedString(client.channel));
			client.channel.isCurrentRevert = true;
			const few = client.channel.revertibles.splice(-editsToRevert, editsToRevert);
			revertSharedStringRevertibles(client.channel, few);
			client.channel.isCurrentRevert = false;
		},
	});

	return withLogging(reducer);
}

export function createSharedStringGeneratorOperations(
	optionsParam?: SharedStringOperationGenerationConfig,
) {
	const options = { ...defaultSharedStringOperationGenerationConfig, ...(optionsParam ?? {}) };

	// All subsequent helper functions are generators; note that they don't actually apply any operations.
	function startPosition({ random, client }: ClientOpState): number {
		return random.integer(0, Math.max(0, client.channel.getLength() - 1));
	}

	function exclusiveRange(state: ClientOpState): RangeSpec {
		const start = startPosition(state);
		const end = state.random.integer(start + 1, state.client.channel.getLength());
		return { start, end };
	}

	function exclusiveRangeLeaveChar(state: ClientOpState): RangeSpec {
		const start = state.random.integer(0, state.client.channel.getLength() - 2);
		const end = state.random.integer(start + 1, state.client.channel.getLength() - 1);
		return { start, end };
	}

	async function addText(state: ClientOpState): Promise<AddText> {
		const { random, client } = state;
		return {
			type: "addText",
			index: random.integer(0, client.channel.getLength()),
			content: random.string(random.integer(1, options.maxInsertLength)),
		};
	}

	async function obliterateRange(state: ClientOpState): Promise<ObliterateRange> {
		if (state.client.channel.getLength() > 0 && state.random.bool(0.2)) {
			return { type: "obliterateRange", ...exclusiveRange(state) };
		}

		const max = state.client.channel.getLength() - 1;
		const num1 = state.random.integer(0, max);
		const num2 = state.random.integer(0, max);
		const start = Math.min(num1, num2);
		const end = Math.max(num1, num2);
		const startSide =
			start === end ? Side.Before : state.random.pick([Side.Before, Side.After]);
		const endSide = start === end ? Side.After : state.random.pick([Side.Before, Side.After]);
		return {
			type: "obliterateRange",
			start: { pos: start, side: startSide },
			end: { pos: end, side: endSide },
		};
	}

	async function annotateRange(state: ClientOpState): Promise<AnnotateRange> {
		const { random } = state;
		const key = random.pick(options.propertyNamePool);
		const value = random.pick([random.string(5), random.handle(), undefined, null]);
		return {
			type: "annotateRange",
			...exclusiveRange(state),
			props: [{ key, value }],
		};
	}

	async function annotateAdjustRange(state: ClientOpState): Promise<AnnotateAdjustRange> {
		const { random } = state;
		const key = random.pick(options.propertyNamePool);
		const max = random.pick([undefined, random.integer(-10, 100)]);
		const min = random.pick([undefined, random.integer(-100, 10)]);
		const value: AdjustParams = {
			delta: random.integer(-5, 5),
			max,
			min: (min ?? max ?? 0) > (max ?? 0) ? undefined : min,
		};
		return {
			type: "annotateAdjustRange",
			...exclusiveRange(state),
			adjust: [{ key, value }],
		};
	}

	async function removeRange(state: ClientOpState): Promise<RemoveRange> {
		return { type: "removeRange", ...exclusiveRange(state) };
	}

	async function removeRangeLeaveChar(state: ClientOpState): Promise<RemoveRange> {
		return { type: "removeRange", ...exclusiveRangeLeaveChar(state) };
	}

	const lengthSatisfies =
		(criteria: (length: number) => boolean): AcceptanceCondition<ClientOpState> =>
		({ client }) =>
			criteria(client.channel.getLength());
	const hasNonzeroLength = lengthSatisfies((length) => length > 0);
	const isShorterThanMaxLength = lengthSatisfies((length) => length < options.maxStringLength);

	return {
		startPosition,
		exclusiveRange,
		exclusiveRangeLeaveChar,
		addText,
		obliterateRange,
		annotateRange,
		annotateAdjustRange,
		removeRange,
		removeRangeLeaveChar,
		lengthSatisfies,
		hasNonzeroLength,
		isShorterThanMaxLength,
	};
}

function setSharedStringRuntimeOptions(runtime: IFluidDataStoreRuntime) {
	runtime.options.intervalStickinessEnabled = true;
	runtime.options.mergeTreeEnableObliterate = true;
	runtime.options.mergeTreeEnableAnnotateAdjust = true;
	runtime.options.mergeTreeEnableSidedObliterate = true;
	runtime.options.mergeTreeEnableObliterateReconnect = true;
}
export class SharedStringFuzzFactory extends SharedStringFactory {
	public async load(
		runtime: IFluidDataStoreRuntime,
		id: string,
		services: IChannelServices,
		attributes: IChannelAttributes,
	): Promise<SharedStringClass> {
		setSharedStringRuntimeOptions(runtime);
		return super.load(runtime, id, services, attributes);
	}

	public create(document: IFluidDataStoreRuntime, id: string): SharedStringClass {
		setSharedStringRuntimeOptions(document);
		return super.create(document, id);
	}
}

export const baseModel: Omit<
	DDSFuzzModel<SharedStringFactory, Operation, FuzzTestState>,
	"workloadName" | "generatorFactory"
> = {
	reducer:
		// makeReducer supports a param for logging output which tracks the provided intervalId over time:
		// { intervalId: "00000000-0000-0000-0000-000000000000", clientIds: ["A", "B", "C"] }
		makeReducer(),
	validateConsistency: async (a, b) => {
		if (hasSharedStringOracle(a.channel)) {
			a.channel.sharedStringOracle.validate();
		}

		if (hasSharedStringOracle(b.channel)) {
			b.channel.sharedStringOracle.validate();
		}

		void assertEquivalentSharedStrings(a.channel, b.channel);
	},
	factory: new SharedStringFuzzFactory(),
	minimizationTransforms: [
		(op) => {
			if (op.type !== "addText") {
				return;
			}
			op.content = op.content.slice(1);
		},
		(op) => {
			switch (op.type) {
				case "addText":
					if (op.index > 0) {
						op.index -= 1;
					}
					break;
				case "removeRange":
				case "annotateRange":
					if (op.start > 0) {
						op.start--;
					}
					if (op.end > 0) {
						op.end--;
					}
					break;
				case "addInterval":
				case "changeInterval": {
					const { startPos, endPos, startSide, endSide } = endpointPosAndSide(
						op.start,
						op.end,
					);
					if (typeof startPos === "number" && startPos > 0) {
						op.start = toSequencePlace(startPos - 1, startSide);
					}
					if (typeof endPos === "number" && endPos > 0) {
						op.end = toSequencePlace(endPos - 1, endSide);
					}
					break;
				}
				default:
					break;
			}
		},
		(op) => {
			if (op.type === "removeRange" || op.type === "annotateRange") {
				if (op.end > 0) {
					op.end--;
				}
			} else if (op.type === "addInterval" || op.type === "changeInterval") {
				const { endPos, endSide } = endpointPosAndSide(op.start, op.end);

				if (typeof endPos === "number" && endPos > 0) {
					op.end = toSequencePlace(endPos - 1, endSide);
				}
			}
		},
	],
};

const oracleEmitter = new TypedEventEmitter<DDSFuzzHarnessEvents>();

oracleEmitter.on("clientCreate", (client) => {
	const channel = client.channel as IChannelWithOracles;

	// Attach SharedString oracle
	const sharedStringOracle = new SharedStringOracle(channel);
	channel.sharedStringOracle = sharedStringOracle;
	registerOracle(sharedStringOracle);
});

export const defaultFuzzOptions: Partial<DDSFuzzSuiteOptions> = {
	validationStrategy: { type: "fixedInterval", interval: 10 },
	reconnectProbability: 0.1,
	numberOfClients: 3,
	clientJoinOptions: {
		maxNumberOfClients: 6,
		clientAddProbability: 0.1,
	},
	defaultTestCount: 100,
	saveFailures: { directory: path.join(_dirname, "../../src/test/fuzz/results") },
	testSquashResubmit: true,
	emitter: oracleEmitter,
};

export function makeIntervalOperationGenerator(
	optionsParam?: IntervalOperationGenerationConfig,
	alwaysLeaveChar: boolean = false,
): AsyncGenerator<Operation, ClientOpState> {
	const {
		startPosition,
		addText,
		obliterateRange,
		removeRange,
		annotateRange,
		removeRangeLeaveChar,
		lengthSatisfies,
		hasNonzeroLength,
		isShorterThanMaxLength,
	} = createSharedStringGeneratorOperations(optionsParam);

	const options = { ...defaultIntervalOperationGenerationConfig, ...(optionsParam ?? {}) };

	function isNonEmpty(collection: ISequenceIntervalCollection): boolean {
		for (const _ of collection) {
			return true;
		}

		return false;
	}

	function inclusiveRange(state: ClientOpState): RangeSpec {
		const start = startPosition(state);
		const end = state.random.integer(
			start,
			Math.max(start, state.client.channel.getLength() - 1),
		);
		return { start, end };
	}

	function inclusiveRangeWithUndefined(
		state: ClientOpState,
	): RangeSpec | { start: undefined; end: undefined } {
		return state.random.bool() ? inclusiveRange(state) : { start: undefined, end: undefined };
	}

	function propertySet(state: ClientOpState): PropertySet {
		const propNamesShuffled = [...options.propertyNamePool];
		state.random.shuffle(propNamesShuffled);
		const propsToChange = propNamesShuffled.slice(
			0,
			state.random.integer(1, propNamesShuffled.length),
		);
		const propSet: PropertySet = {};
		for (const name of propsToChange) {
			propSet[name] = state.random.string(5);
		}
		return propSet;
	}

	function propertySetWithUndefined(state: ClientOpState): PropertySet | undefined {
		return state.random.bool() ? propertySet(state) : undefined;
	}

	function nonEmptyIntervalCollection({ client, random }: ClientOpState): string {
		const nonEmptyLabels = Array.from(client.channel.getIntervalCollectionLabels()).filter(
			(label) => {
				const collection = client.channel.getIntervalCollection(label);
				return isNonEmpty(collection);
			},
		);
		return random.pick(nonEmptyLabels);
	}

	function interval(state: ClientOpState): { collectionName: string; id: string } {
		const collectionName = nonEmptyIntervalCollection(state);
		const intervals = Array.from(state.client.channel.getIntervalCollection(collectionName));
		const id = state.random.pick(intervals)?.getIntervalId();
		assert(id);

		return {
			id,
			collectionName,
		};
	}

	async function addInterval(state: ClientOpState): Promise<AddInterval> {
		const { start, end } = inclusiveRange(state);
		return {
			type: "addInterval",
			collectionName: state.random.pick(options.intervalCollectionNamePool),
			id: state.random.uuid4(),
			start: toSequencePlace(start, state.random.pick([Side.Before, Side.After, undefined])),
			end: toSequencePlace(end, state.random.pick([Side.Before, Side.After, undefined])),
		};
	}

	async function deleteInterval(state: ClientOpState): Promise<DeleteInterval> {
		return {
			type: "deleteInterval",
			...interval(state),
		};
	}

	async function changeInterval(state: ClientOpState): Promise<ChangeInterval> {
		const { start, end } = inclusiveRangeWithUndefined(state);
		const properties = propertySetWithUndefined(state);
		return {
			type: "changeInterval",
			start: toOptionalSequencePlace(
				start,
				state.random.pick([Side.Before, Side.After, undefined]),
			),
			end: toOptionalSequencePlace(
				end,
				state.random.pick([Side.Before, Side.After, undefined]),
			),
			properties,
			...interval(state),
		};
	}

	const hasAnInterval = ({ client }: ClientOpState): boolean =>
		Array.from(client.channel.getIntervalCollectionLabels()).some((label) => {
			const collection = client.channel.getIntervalCollection(label);
			return isNonEmpty(collection);
		});

	const hasNotTooManyIntervals: AcceptanceCondition<ClientOpState> = ({ client }) => {
		let intervalCount = 0;
		for (const label of client.channel.getIntervalCollectionLabels()) {
			for (const _ of client.channel.getIntervalCollection(label)) {
				intervalCount++;
				if (intervalCount >= options.maxIntervals) {
					return false;
				}
			}
		}
		return true;
	};

	const all =
		<T>(...clauses: AcceptanceCondition<T>[]): AcceptanceCondition<T> =>
		(t: T) =>
			clauses.reduce<boolean>((prev, cond) => prev && cond(t), true);
	const usableWeights =
		optionsParam?.weights ?? defaultIntervalOperationGenerationConfig.weights;
	return createWeightedAsyncGenerator<Operation, ClientOpState>([
		[addText, usableWeights.addText, isShorterThanMaxLength],
		[
			alwaysLeaveChar ? removeRangeLeaveChar : removeRange,
			usableWeights.removeRange,
			alwaysLeaveChar
				? lengthSatisfies((length) => {
						return length > 1;
					})
				: hasNonzeroLength,
		],
		[annotateRange, usableWeights.annotateRange, hasNonzeroLength],
		[obliterateRange, usableWeights.obliterateRange, hasNonzeroLength],
		[addInterval, usableWeights.addInterval, all(hasNotTooManyIntervals, hasNonzeroLength)],
		[deleteInterval, usableWeights.deleteInterval, hasAnInterval],
		[changeInterval, usableWeights.changeInterval, all(hasAnInterval, hasNonzeroLength)],
	]);
}

export const baseIntervalModel = {
	...baseModel,
	generatorFactory: () =>
		takeAsync(100, makeIntervalOperationGenerator(defaultIntervalOperationGenerationConfig)),
};

export function makeSharedStringOperationGenerator(
	optionsParam?: SharedStringOperationGenerationConfig,
	alwaysLeaveChar: boolean = false,
): AsyncGenerator<Operation, FuzzTestState> {
	const {
		addText,
		removeRange,
		annotateRange,
		annotateAdjustRange,
		removeRangeLeaveChar,
		lengthSatisfies,
		obliterateRange,
		hasNonzeroLength,
		isShorterThanMaxLength,
	} = createSharedStringGeneratorOperations(optionsParam);

	const usableWeights =
		optionsParam?.weights ?? defaultIntervalOperationGenerationConfig.weights;
	return createWeightedAsyncGenerator<Operation, FuzzTestState>([
		[addText, usableWeights.addText, isShorterThanMaxLength],
		[
			alwaysLeaveChar ? removeRangeLeaveChar : removeRange,
			usableWeights.removeRange,
			alwaysLeaveChar
				? lengthSatisfies((length) => {
						return length > 1;
					})
				: hasNonzeroLength,
		],
		// TODO:AB#17785: Once sided obliterates support specifying the start/end of the sequence,
		// we can drop the `hasNonzeroLength` condition here and adjust the obliterate generation logic
		// to get coverage of that in fuzz testing.
		[obliterateRange, usableWeights.obliterateRange, hasNonzeroLength],
		[annotateRange, usableWeights.annotateRange, hasNonzeroLength],
		[annotateAdjustRange, usableWeights.annotateRange, hasNonzeroLength],
	]);
}

export const baseSharedStringModel = {
	...baseModel,
	generatorFactory: () =>
		takeAsync(
			100,
			makeSharedStringOperationGenerator(defaultIntervalOperationGenerationConfig),
		),
};
